再來要做 Lightning 的文章頁面,上篇新增的文章終於可以看到了。
文章頁面比較複雜一點點,我們把它抽出一個單獨的 ShowPost Controller,加上 -i
會新增 Single Action Controller:
php artisan make:controller Post/ShowPost -i
要先排除掉 Resource 裡的 show
路由,和新增新的路由:
routes/web.php
Route::resource('posts', 'Post\PostController')->except('show');
Route::get('posts/{post}', 'Post\ShowPost');
顧名思義,Single Action Controller 裡面只有一個方法,這個方法要取名 __invoke()
:
app/Http/Controllers/Post/ShowPost.php
public function __invoke(Post $post)
{
$post->increment('visits');
return Inertia::render('Post/Show', [
'post' => PostPresenter::make($post)->get(),
]);
}
在 Post 裡有個紀錄瀏覽次數的欄位 visits
,可以使用 increment()
讓每次瀏覽都會自動加1。
可是 PostPresenter
裡的欄位沒有文章內容 content
和文章作者欄位,這裡要介紹一個 Flexible Presenter 的功能 preset()
,例如在 PostPresenter
定義一個 presetShow()
方法,在呼叫處就可以用 ->preset('show')
呼叫此方法。先串接自訂的 preset:
app/Http/Controllers/Post/ShowPost.php
PostPresenter::make($post)
->preset('show')
->get()
而這個自訂的 preset 可以做些什麼呢?可以串 only()
、except()
或 with()
,前兩個就是指定欄位和排除欄位,不過現在要用的是 with()
。with()
可以增加自訂欄位,接收一個閉包函數,函數的參數是在 Presenter::make()
丟進去的 Model (Post)。
然後我們就可以增加缺失的 content
(文章內容) & author
(文章作者),作者即關聯的 User Model,同樣可以套 UserPresenter:
app/Presenters/PostPresenter.php
public function presetShow()
{
return $this->with(fn (Post $post) => [
'content' => $post->content,
'author' => fn () => UserPresenter::make($post->author)
->preset('withCount')
->get(),
]);
}
這裡的 UserPresenter
也套用了 preset,同樣也要定義 presetWithCount()
,postsCount
欄位是作者全部文章的數量:
app/Presenters/UserPresenter.php
public function presetWithCount()
{
return $this->with(fn (User $user) => [
'postsCount' => $user->posts()->count(),
]);
}
然後前端文章頁面:
這裡說一下英文單字太長會破版的坑,通常有些英文單字太長時,可以使用
overflow-wrap: break-word
(Tailwind CSS 對應.break-words
) 來強制段行。但這裡做完之後,開手機板的寬度還是破版,因為這裡用 Grid 排版,踩到了 Grid 的坑 (Flex 也會遇到此問題),解決方案之一是在 Grid 的元素的子元素,加上min-width: 0
(Tailwind CSS 對應.min-w-0
)。參考:Preventing a Grid Blowout。
resources/js/Pages/Post/Show.vue
<template>
<div class="py-6 md:py-8">
<alert v-if="$page.flash.success" class="shadow mb-6">{{ $page.flash.success }}</alert>
<div class="grid gap-6 xl:grid-cols-4">
<div class="card p-6 md:p-8 min-w-0 xl:col-span-3">
<h1 class="text-3xl font-semibold leading-snug">{{ post.title }}</h1>
<div class="flex space-x-4 mt-2 text-sm">
<div>
<icon class="text-purple-500" icon="heroicons-outline:clock" />
<span class="text-gray-500">{{ post.created_at }}</span>
</div>
<div>
<icon class="text-purple-500" icon="heroicons-outline:eye" />
<span class="text-gray-500">{{ post.visits }}</span>
</div>
</div>
<div class="mt-6 font-light break-words">{{ post.content }}</div>
</div>
<div>
<div class="card p-6 md:p-8 sticky top-8">
<inertia-link :href="`/user/${post.author.id}`">
<img :src="post.author.avatar" class="rounded-full w-20 h-20 mx-auto">
</inertia-link>
<div class="mt-4 text-center">
<div class="text-2xl font-semibold">
<inertia-link :href="`/user/${post.author.id}`" class="hover:text-purple-500">
{{ post.author.name }}
</inertia-link>
</div>
<div v-if="post.author.description" class="mt-2 text-gray-600 font-light">
{{ post.author.description }}
</div>
<div class="flex justify-center items-center space-x-6 mt-3">
<inertia-link :href="`/user/${post.author.id}`" class="link font-light">
<icon icon="heroicons-outline:book-open" />
文章 {{ post.author.postsCount }}
</inertia-link>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import AppLayout from '@/Layouts/AppLayout'
import Alert from '@/Components/Alert'
export default {
layout: AppLayout,
metaInfo() {
this.addMeta('description', this.post.description)
this.addMetaWithProperty('og:type', 'website')
this.addMetaWithProperty('og:title', this.post.title)
this.addMetaWithProperty('og:description', this.post.description)
this.addMetaWithProperty('og:image', this.post.thumbnail)
this.addMetaWithProperty('og:url', location.href)
this.addMeta('twitter:title', this.post.title)
this.addMeta('twitter:description', this.post.description)
this.addMeta('twitter:url', location.href)
this.addMeta('twitter:card', 'summary_large_image')
this.addMeta('twitter:image', this.post.thumbnail)
return {
title: this.post.title,
meta: this.meta
}
},
components: {
Alert
},
props: {
post: Object
},
data() {
return {
meta: []
}
},
methods: {
addMeta(name, content) {
if (content) this.meta.push({ name, content })
},
addMetaWithProperty(property, content) {
if (content) this.meta.push({ property, content })
}
}
}
</script>
開 /posts/1
就可以看到新增的第1篇文章了:
文章要分享,SEO 自然不能少,Meta 交給 Vue Meta 了。可是 Vue Meta 只會在前端渲染啊!沒關係,之後會解決。
文章還有個欄位叫 published
(發布),可以讓文章作者決定要不要發布文章,正常在未發布(草稿)時,應該是除了作者的其他人就不能看該文章。所以現在要來新增 PostPolicy:
php artisan make:policy PostPolicy --model=Post
view()
方法就是管顯示單個資源的授權,為了兼容未登入使用者這裡要用 ?User
標示為可選,$user
也要包 optional()
。若此用戶有登入且為作者,不管是不是草稿,都可以正常瀏覽。若不是就只能瀏覽已發布的文章:
app/Policies/PostPolicy.php
public function view(?User $user, Post $post)
{
return optional($user)->id === $post->author_id
? true
: $post->published === true;
}
還有在 ShowPost Controller 裡增加 authorize()
來驗證用戶:
app/Http/Controllers/Post/ShowPost.php
public function __invoke(Post $post)
{
$this->authorize('view', $post);
...
}
剛才的文章數量是直接拿作者所有文章的數量,包括草稿,但草稿數不需要讓其他用戶知道,所以要改一下:
app/Presenters/UserPresenter.php
public function presetWithCount()
{
return $this->with(fn (User $user) => [
'postsCount' => $user->publishedPosts()->count(),
]);
}
阿這 publishedPosts()
是不存在的ㄝ,要去 User Model 裡增加:
app/User.php
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function publishedPosts()
{
return $this->posts()->published();
}
這 published()
也不存在,去 Post Model 定義 scope
,在組 Query Builder 時就可以呼叫,順帶連 unpublished()
也一起加上:
app/Post.php
public function scopePublished($query)
{
return $query->where('published', true);
}
public function scopeUnpublished($query)
{
return $query->where('published', false);
}
然後開 Tinker 把文章設定成未發布:
php artisan tinker
>>> App\Post::find(1)->update(['published' => false])
用無痕模式開 /posts/1
看看:
嗯!正常。改成發布狀態:
>>> App\Post::find(1)->update(['published' => true])
再用無痕模式打開:
現在我們就可以在頁面裡增加一個草稿的標示,讓作者知道這篇文章尚未發布:
resources/js/Pages/Post/Show.vue
<h1 class="text-3xl font-semibold leading-snug">{{ post.title }}</h1>
<div class="flex space-x-4 mt-2 text-sm">
...
<div v-if="!post.published">
<span class="px-2 py-1 bg-green-100 text-green-700">草稿</span>
</div>
</div>
完成!
最後要講的是優化瀏覽人次,現在如果你一直重新整理頁面,瀏覽次數就會一直飆升,且容易被灌水。這裡我們可以增加一些條件。需要是非作者的其他讀者,且沒有瀏覽過的 Session 紀錄,才會增加瀏覽次數:
public function __invoke(Post $post)
{
...
$this->incrementVisit($post);
...
}
protected function incrementVisit(Post $post)
{
if (! optional($this->user())->can('view', $post) &&
! session("posts:visits:{$post->id}")
) {
$post->increment('visits');
session()->put("posts:visits:{$post->id}", true);
}
}
現在在無痕模式打開文章,瀏覽次數只會+1,且重新整理不會一直增加瀏覽人次。
現在至少有個簡單的文章頁面,新增的文章終於可以看了。當然我們還不滿足,下篇我們改成用 Markdown,大家很熟悉的語法,敬請期待~~
Lightning 範例程式碼:https://github.com/ycs77/lightning